昨天我們學會了測試替身,解決了外部依賴的測試問題。今天面對一個新的挑戰:「如何測試程式在出錯時的行為?」
想像一個場景:你的應用需要處理各種錯誤情況:
很多開發者只測試「快樂路徑」(Happy Path),但真實世界充滿了意外。今天我們要學習如何徹底測試例外處理。
今天結束後,你將學會:
toThrow()
和 rejects.toThrow()
斷言// 問題:只考慮成功情況的程式碼
class UserService {
async getUserProfile(userId) {
const response = await fetch(`/api/users/${userId}`)
const user = await response.json() // 如果不是 JSON 格式會怎樣?
return {
id: user.id,
name: user.name.toUpperCase() // 如果 name 是 undefined 會怎樣?
}
}
}
例外處理測試確保:
建立 src/day08/validator.js
export class ValidationError extends Error {
constructor(message, field = null) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
export function validateEmail(email) {
if (!email || typeof email !== 'string') {
throw new ValidationError('Email is required', 'email');
}
if (!email.includes('@')) {
throw new ValidationError('Email must contain @ symbol', 'email');
}
return true;
}
export function divide(a, b) {
if (b === 0) {
throw new Error('Division by zero is not allowed');
}
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('Both arguments must be numbers');
}
return a / b;
}
建立 tests/day08/sync-exceptions.test.js
import { describe, it, expect } from 'vitest'
import { validateEmail, ValidationError, divide } from '../../src/day08/validator.js'
describe('synchronous exception handling tests', () => {
describe('validateEmail', () => {
it('throwsErrorWhenEmailIsMissing', () => {
expect(() => validateEmail()).toThrow('Email is required')
expect(() => validateEmail('')).toThrow('Email is required')
})
it('throwsErrorWhenEmailFormatIsInvalid', () => {
expect(() => validateEmail('invalid-email')).toThrow('Email must contain @ symbol')
})
it('throwsValidationErrorWithCorrectField', () => {
try {
validateEmail('invalid')
} catch (error) {
expect(error).toBeInstanceOf(ValidationError)
expect(error.field).toBe('email')
expect(error.message).toContain('@ symbol')
}
})
it('doesNotThrowWhenEmailIsValid', () => {
expect(() => validateEmail('user@example.com')).not.toThrow()
})
})
describe('divide function', () => {
it('throwsErrorForDivisionByZero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero is not allowed')
})
it('doesNotThrowForValidInputs', () => {
expect(() => divide(10, 2)).not.toThrow()
expect(divide(15, 3)).toBe(5)
})
})
})
建立 src/day08/user-service.js
export class UserService {
constructor(httpClient) {
this.httpClient = httpClient
}
async fetchUser(userId) {
if (!userId || userId <= 0) {
throw new Error('Invalid user ID')
}
const response = await this.httpClient.get(`/users/${userId}`)
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`)
}
const user = await response.json()
if (!user.id || !user.name) {
throw new Error('Invalid user data received')
}
return user
}
}
建立 tests/day08/async-exceptions.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserService } from '../../src/day08/user-service.js'
describe('asynchronous exception handling tests', () => {
let userService, mockHttpClient
beforeEach(() => {
mockHttpClient = { get: vi.fn() }
userService = new UserService(mockHttpClient)
})
it('rejectsWithErrorForInvalidUserId', async () => {
await expect(userService.fetchUser()).rejects.toThrow('Invalid user ID')
})
it('rejectsForHttpErrors', async () => {
mockHttpClient.get.mockResolvedValue({ ok: false, status: 404 })
await expect(userService.fetchUser(1))
.rejects.toThrow('Failed to fetch user: 404')
})
it('resolvesForValidUserData', async () => {
const mockUser = { id: 1, name: 'John Doe' }
mockHttpClient.get.mockResolvedValue({
ok: true,
json: async () => mockUser
})
await expect(userService.fetchUser(1)).resolves.toEqual(mockUser)
})
})
建立 src/day08/form-validator.js
export class FormValidationError extends Error {
constructor(errors) {
super('Form validation failed')
this.name = 'FormValidationError'
this.errors = errors
}
}
export class UserForm {
static validate(formData) {
const errors = {}
if (!formData.email) {
errors.email = 'Email is required'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Invalid email format'
}
if (!formData.password) {
errors.password = 'Password is required'
} else if (formData.password.length < 8) {
errors.password = 'Password must be at least 8 characters'
}
if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = 'Passwords do not match'
}
if (Object.keys(errors).length > 0) {
throw new FormValidationError(errors)
}
return true
}
}
建立 tests/day08/form-validator.test.js
import { describe, it, expect } from 'vitest'
import { UserForm, FormValidationError } from '../../src/day08/form-validator.js'
describe('form validator exception tests', () => {
it('throwsFormValidationErrorForMissingFields', () => {
expect(() => UserForm.validate({})).toThrow(FormValidationError)
try {
UserForm.validate({})
} catch (error) {
expect(error.errors).toHaveProperty('email', 'Email is required')
expect(error.errors).toHaveProperty('password', 'Password is required')
}
})
it('throwsErrorForInvalidEmail', () => {
const formData = {
email: 'invalid-email',
password: 'ValidPass123',
confirmPassword: 'ValidPass123'
}
try {
UserForm.validate(formData)
} catch (error) {
expect(error).toBeInstanceOf(FormValidationError)
expect(error.errors.email).toBe('Invalid email format')
}
})
it('doesNotThrowForValidFormData', () => {
const validFormData = {
email: 'user@example.com',
password: 'ValidPass123',
confirmPassword: 'ValidPass123'
}
expect(() => UserForm.validate(validFormData)).not.toThrow()
expect(UserForm.validate(validFormData)).toBe(true)
})
})
// ✅ 好:驗證錯誤類型和內容
try {
validateEmail('invalid')
} catch (error) {
expect(error).toBeInstanceOf(ValidationError)
expect(error.field).toBe('email')
expect(error.message).toContain('@ symbol')
}
// ❌ 壞:只檢查有錯誤
expect(() => validateEmail('invalid')).toThrow()
// ❌ 錯誤:沒有等待異步
it('handleAsyncError', () => {
expect(() => asyncFunction()).toThrow() // 錯誤!
})
// ✅ 正確:使用 rejects
it('handleAsyncErrorCorrectly', async () => {
await expect(asyncFunction()).rejects.toThrow('Expected error')
})
完整實作 tests/day08/test-shopping-cart-exceptions.js
import { describe, it, expect, vi } from 'vitest'
class ECommerceException extends Error {
constructor(message, code = null) {
super(message)
this.name = 'ECommerceException'
this.code = code
}
}
class ShoppingCart {
constructor(inventory) {
this.inventory = inventory
this.items = []
}
addItem(productId, quantity = 1) {
if (!productId) {
throw new ECommerceException('Product ID is required', 'INVALID_PRODUCT_ID')
}
if (quantity <= 0) {
throw new ECommerceException('Quantity must be positive', 'INVALID_QUANTITY')
}
const product = this.inventory.getProduct(productId)
if (!product) {
throw new ECommerceException(`Product not found: ${productId}`, 'PRODUCT_NOT_FOUND')
}
if (!this.inventory.isAvailable(productId, quantity)) {
throw new ECommerceException('Insufficient stock', 'INSUFFICIENT_STOCK')
}
this.items.push({ productId, quantity })
}
}
describe('shopping cart exception handling', () => {
it('handlesAllErrorCases', () => {
const mockInventory = {
getProduct: id => id === 'valid' ? { price: 10 } : null,
isAvailable: (id, qty) => id === 'valid' && qty <= 5
}
const cart = new ShoppingCart(mockInventory)
// 測試無效商品 ID
expect(() => cart.addItem(null))
.toThrow(new ECommerceException('Product ID is required', 'INVALID_PRODUCT_ID'))
// 測試商品不存在
expect(() => cart.addItem('nonexistent'))
.toThrow('Product not found')
// 測試庫存不足
expect(() => cart.addItem('valid', 10))
.toThrow('Insufficient stock')
// 測試成功添加
expect(() => cart.addItem('valid', 2)).not.toThrow()
expect(cart.items).toHaveLength(1)
})
})
我們現在在測試基礎概念的倒數第三天,已經掌握了大部分核心測試技術:
基礎概念 (1-10)
├── Day 1: 環境設定 ✅
├── Day 2: 基本斷言 ✅
├── Day 3: TDD 循環 ✅
├── Day 4: 測試結構 ✅
├── Day 5: 生命週期 ✅
├── Day 6: 參數化測試 ✅
├── Day 7: 測試替身 ✅
├── Day 8: 例外處理測試 📍 今天
├── Day 9: 測試覆蓋率
└── Day 10: 重構技巧
透過今天的學習,我們掌握了:
toThrow()
、rejects.toThrow()
等方法例外處理測試讓我們能夠確保程式優雅地處理錯誤,提供有用的錯誤訊息,並維持系統的穩定性。
今天我們學會了例外處理測試,確保程式在各種錯誤情況下都能正確回應。明天我們將學習「測試覆蓋率」,了解如何衡量測試的完整性。
記住:好的例外處理測試不只是檢查是否拋出錯誤,更要驗證錯誤類型、錯誤訊息,以及錯誤發生後的系統狀態!